通过实战篇:导航预加载可知,在一个 Service Worker 尚未启动的页面中,由于浏览器会等到 Service Worker 启动后才发起导航请求,且 Service Worker 启动可能会存在不同程度的延迟,该延迟将直接导致导航请求的延迟,进而增加了页面的整体渲染时间。为解决该问题,我们可以通过导航预加载机制让 Service Worker 的启动与导航请求并行执行,从而避免因 Service Worker 启动延迟而导致的页面渲染缓慢问题。由于我们已对相关底层 API 的使用进行了详细说明,故本章不再重述,而是直接讨论 Workbox 下导航预加载的使用。

# 基本使用

首先我们需要调用以下方法来启用导航预加载功能:

workbox.navigationPreload.enable();

然后我们可通过 workbox.routing.registerNavigationRoute 方法注册导航请求路由:

workbox.routing.registerNavigationRoute(
  workbox.precaching.getCacheKeyForURL('/single-page-app.html')
);

上述代码的效果是:当用户访问站点时,将使用预缓存资源 /single-page-app.html 来响应所有的导航请求,由于方法 workbox.routing.registerNavigationRoute 默认使用预缓存资源进行响应,如果想要自定义响应缓存的来源,可通过以下方式实现:

workbox.routing.registerNavigationRoute(
  'custom-cache-key',
  {
    cacheName: 'custom-cache-name'
  }
);

有时我们可能只想要 /single-page-app.html 来响应部分导航请求,此时可通过设置 whitelistblacklist 属性来实现,比如:

workbox.routing.registerNavigationRoute(
  workbox.precaching.getCacheKeyForURL('/single-page-app.html'),
  {
    whitelist: [
      new RegExp('/blog/')
    ],
    blacklist: [
      new RegExp('/blog/restricted/')
    ]
  }
);

通过配置,只有在满足导航请求路径以 /blog/ 开头且不以 /blog/restricted/ 开头的情况下,才会使用缓存 /single-page-app.html 来响应该请求。

注:属性 whitelistblacklist 的值为正则表达式数组。

使用方法 workbox.routing.registerNavigationRoute 注册的导航请求路由采用的是缓存优先的请求策略,如果想要使用其他请求策略,可使用如下方式进行注册:

const strategy = new workbox.strategies.NetworkFirst(...);
const navigationRoute = new workbox.routing.NavigationRoute(strategy, {
  whitelist: [],
  blacklist: []
});
workbox.routing.registerRoute(navigationRoute);

也可使用自定义请求处理逻辑,比如:

const handlerCb = ({ url, event, request, params }) => {
  return Promise.resolve(new Response(...));
};
const navigationRoute = new workbox.routing.NavigationRoute(handlerCb, {
  whitelist: [],
  blacklist: []
});
workbox.routing.registerRoute(navigationRoute);

# 综合运用

至此,我们完成了 Workbox 中预缓存、路由设置、请求策略、缓存置换策略及导航预加载的学习,下面我们将通过具体示例来看一下它们的综合运用(本示例代码仓库为:github.com/nanjingboy/…)。

首先我们需要使用 workbox-webpack-plugin 来动态生成预缓存资源列表:

// webpack.config.js
const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = {
  //... 其他配置
  plugins: [
    //... 其他插件
    new InjectManifest({
      swSrc: './client/sw.js',
      swDest: 'sw.js',
      importWorkboxFrom: 'local'
    })
  ]
};

接下来,在 client/sw.js 中注册预缓存路由:

self.__precacheManifest = [].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});

self.__precacheManifest 的默认值来自通过第一步的 webpack 动态生成的 precache-manifest.[hash].js 文件,且在构建时自动生成引入该文件,故无需我们手动处理。

然后,在 client/sw.js中启动导航预加载并注册导航请求路由:

workbox.navigationPreload.enable();

const navigationRoute = new workbox.routing.NavigationRoute(workbox.streams.strategy([
  ({ url }) => fetchShell(url, 'top'),
  ({ event }) => fetchPageContent(event),
  ({ url }) => fetchShell(url, 'bottom')
]));
workbox.routing.registerRoute(navigationRoute);

示例中,我们使用了应用 Shell 架构,而使用 workbox.routing.registerNavigationRoute 方法注册的导航路由并不适用于该架构,因此我们使用了上述较为繁琐的方式进行导航路由的注册。

workbox.routing.NavigationRoute 的构造函数中,我们调用了 workbox.streams.strategy 方法,并在其参数中,调用了函数 fetchShellfetchPageContent,它们的主要实现如下:

async function fetchShell(url, type) {
  const { pathname } = new URL(url, location);
  let shellUrl;
  if (pathname === '/') {
    shellUrl = `/shell/home_${type}.html`;
  } else if (/^\/create|\/edit\/\d+$/.test(pathname)) {
    shellUrl = `/shell/edit_${type}.html`;
  } else if (/^\/detail\/\d+$/.test(pathname)) {
    shellUrl = `/shell/detail_${type}.html`;
  }
  const cache = await caches.open(workbox.core.cacheNames.precache);
  return await cache.match(workbox.precaching.getCacheKeyForURL(shellUrl));
}

async function fetchPageContent(event) {
  const cacheName = 'page-content';
  try {
    const { request: { url } } = event;
    const preloadResponse = await event.preloadResponse;
    if (preloadResponse) {
      const clonePreloadResponse = preloadResponse.clone();
      event.waitUntil((async () => {
        const cache = await caches.open(cacheName);
        await cache.put(url, clonePreloadResponse);
      })());
      return preloadResponse;
    }
  } catch {
  }
  const networkFirst = new workbox.strategies.NetworkFirst({
    cacheName,
    plugins: [
      new workbox.expiration.Plugin({
        maxAgeSeconds: 24 * 60 * 60
      })
    ],
    fetchOptions: {
      headers: {
        'only_content': 1
      }
    }
  })
  return await networkFirst.handle({ event });
}

在 fetchShell 中:

  • 首先根据请求 URL 得到相关 Shell 文件 的路径;
  • 然后通过 workbox.core.cacheNames.precache 获得预缓存名,并调用 caches.open 打开相关缓存并获得相关实例 cache;
  • 最后通过 workbox.precaching.getCacheKeyForURL 获得指定资源的 cache key,并调用 cache.match 获取相关资源并返回。

在 fetchPageContent 中:

  • 首先尝试从导航预加载请求中获得响应,如果请求成功便缓存并返回相关响应;
  • 如果无法从导航预加载请求中获得响应,则使用网络优先策略来获得相关响应。
  • 在 fetchPageContent 中,我们使用了模块 workbox.strategiesworkbox.expiration,且我们已经在基本配置中讨论过使用 workbox.* 模块时需注意模块异步加载的问题,因此需要在 Service Worker 脚本的全局作用域中调用以下方法来避免相关问题:
workbox.loadModule('workbox-strategies');
workbox.loadModule('workbox-expiration');

# workbox.streams

上文中,我们提到了 workbox.streams,该模块是对 ReadableStream 的封装,主要有以下方法:

  • isSupported:用来判断当前浏览器是否支持 ReadableStream
  • concatenate:该方法通过 ReadableStream 来处理所接收的 Promise<Response|ReadableStream|BodyInit> 数组,并返回结构为 {done: Promise, stream: ReadableStream} 的对象。
  • concatenateToResponse:该方法是对 concatenate 的进一步封装,它将 concatenate 的返回值转换成结构为 {done: Promise, response: Response} 的对象。
  • strategy:该方法是对 concatenateToResponse 的进一步封装,它接收签名为:({ url, request, event, params }) => Response|ReadableStream|BodyInit|Promise<Response|ReadableStream|BodyInit> 的函数数组,并返回一个签名为:({ url, request, event, params }) => Promise<Response> 的函数

concatenateToResponsestrategy 方法均可设置 headers信息(通过方法的最后一次参数),默认值为:

{ 'Content-Type': 'text/html' }

在实际应用中,我们应优先使用 strategy 方法,这是因为:

  • 该方法可直接作为 workbox 路由的 handler 参数。
  • 如果浏览器不支持 ReadableStream,无需我们做任何判断,该方法将自动降级使用 Promise.all

# 总结

本章我们首先对 Workbox 中导航预加载的使用进行了简单介绍,接下来通过一个示例对前面章节中的预缓存、路由设置、请求策略、缓存置换策略及导航预加载进行了复习,最后我们对模块 workbox.streams 进行了简单介绍。下一章,我们将对可缓存对象进行讨论。

阅读全文